route.ts 3.1 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
  1. import { promises as fs, createReadStream } from 'fs';
  2. import path from 'path';
  3. import { NextRequest, NextResponse } from 'next/server';
  4. import { CLASSROOMS_DIR, isValidClassroomId } from '@/lib/server/classroom-storage';
  5. import { createLogger } from '@/lib/logger';
  6. const log = createLogger('ClassroomMedia');
  7. const MIME_TYPES: Record<string, string> = {
  8. '.png': 'image/png',
  9. '.jpg': 'image/jpeg',
  10. '.jpeg': 'image/jpeg',
  11. '.webp': 'image/webp',
  12. '.gif': 'image/gif',
  13. '.mp4': 'video/mp4',
  14. '.webm': 'video/webm',
  15. '.mp3': 'audio/mpeg',
  16. '.wav': 'audio/wav',
  17. '.ogg': 'audio/ogg',
  18. '.aac': 'audio/aac',
  19. };
  20. export async function GET(
  21. _req: NextRequest,
  22. { params }: { params: Promise<{ classroomId: string; path: string[] }> },
  23. ) {
  24. const { classroomId, path: pathSegments } = await params;
  25. // Validate classroomId
  26. if (!isValidClassroomId(classroomId)) {
  27. return NextResponse.json({ error: 'Invalid classroom ID' }, { status: 400 });
  28. }
  29. // Validate path segments — no traversal
  30. const joined = pathSegments.join('/');
  31. if (joined.includes('..') || pathSegments.some((s) => s.includes('\0'))) {
  32. return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
  33. }
  34. // Only allow media/ and audio/ subdirectories
  35. const subDir = pathSegments[0];
  36. if (subDir !== 'media' && subDir !== 'audio') {
  37. return NextResponse.json({ error: 'Invalid path' }, { status: 404 });
  38. }
  39. const filePath = path.join(CLASSROOMS_DIR, classroomId, ...pathSegments);
  40. const resolvedBase = path.resolve(CLASSROOMS_DIR, classroomId);
  41. try {
  42. // Resolve symlinks and verify the real path stays within the classroom dir
  43. const realPath = await fs.realpath(filePath);
  44. if (!realPath.startsWith(resolvedBase + path.sep) && realPath !== resolvedBase) {
  45. return NextResponse.json({ error: 'Not found' }, { status: 404 });
  46. }
  47. const stat = await fs.stat(realPath);
  48. if (!stat.isFile()) {
  49. return NextResponse.json({ error: 'Not found' }, { status: 404 });
  50. }
  51. const ext = path.extname(realPath).toLowerCase();
  52. const contentType = MIME_TYPES[ext] || 'application/octet-stream';
  53. // Stream the file to avoid loading large videos into memory
  54. const stream = createReadStream(realPath);
  55. const webStream = new ReadableStream({
  56. start(controller) {
  57. stream.on('data', (chunk: Buffer | string) => controller.enqueue(chunk));
  58. stream.on('end', () => controller.close());
  59. stream.on('error', (err) => controller.error(err));
  60. },
  61. cancel() {
  62. stream.destroy();
  63. },
  64. });
  65. return new NextResponse(webStream, {
  66. status: 200,
  67. headers: {
  68. 'Content-Type': contentType,
  69. 'Content-Length': String(stat.size),
  70. 'Cache-Control': 'public, max-age=86400, immutable',
  71. },
  72. });
  73. } catch (error) {
  74. if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
  75. return NextResponse.json({ error: 'Not found' }, { status: 404 });
  76. }
  77. log.error(
  78. `Classroom media serving failed [classroomId=${classroomId}, path=${joined}]:`,
  79. error,
  80. );
  81. return NextResponse.json({ error: 'Internal error' }, { status: 500 });
  82. }
  83. }